Space flight layer: ship/asteroid collision, mining, and composite asteroid destruction - #9
Draft
LundstedtAdam wants to merge 22 commits into
Draft
Space flight layer: ship/asteroid collision, mining, and composite asteroid destruction#9LundstedtAdam wants to merge 22 commits into
LundstedtAdam wants to merge 22 commits into
Conversation
Ship now slides off planets/moons on contact (analytic sphere test, push-out + tangential-velocity retention) instead of flying through them, gated to piloting mode so descent/ascent cinematics are untouched. No acceleration term is added, so the deliberate "no gravity well" flight model is preserved. Also fixes the thruster glow (previously a random flicker unrelated to throttle) to track actual throttle magnitude, adds a pooled engine-exhaust particle trail, and an engine hum whose gain/pitch track throttle — all reading the existing shipTelemetry hot-path singleton rather than the reactive store.
Replace the belt's unseeded Math.random() generation with a pure function of (tier, variant, index, seed) using the existing cellHash/seedFromName primitives, so the layout no longer reshuffles on every quality-tier change and a given index always resolves to the same physical rock — required for narrative content to anchor to a specific asteroid later, and for any future collision/fracture state to stay in sync with the render matrices. Also folds in structured (sector-weighted) placement: 24 angular sectors get a deterministic density multiplier (gap/sparse/normal/ dense), biasing which sector an index lands in without changing how many asteroids exist per tier, so gaps read as flyable corridors instead of uniform noise.
Asteroids get a parallel plain-array-of-structs (position, radius, health, alive/indestructible flags) index-aligned with each tier's InstancedMesh, built via the same deterministic placement the belt already uses so the state array and render matrices never disagree about which (tier, variant, i) exists. A cylindrical spatial grid (radial x angular bins, sized for the belt's thin-torus shape) gives O(1)-amortized nearby-asteroid queries instead of scanning up to 9000 instances. Both are published on a new asteroidRuntime singleton (shipTelemetry's hot-path-singleton convention) along with a killAsteroid callback that zeroes a dead asteroid's render instance and drops it from the grid, so upcoming collision/mining/fracture code can destroy an asteroid without reaching into AsteroidBelt's internal Three.js objects.
Ship now slides off asteroids on contact using the same push-out + tangential-retain response as planet collision, broadphased through the belt's spatial grid instead of scanning every instance. Since the grid is indexed in belt-local space (the belt rotates as one group each frame), the query/response rotate the ship's position and velocity into that frame and back via the belt's published yaw. Generalizes the previously-private sphere-vs-sphere resolver into a shared resolveSphereContact used by both planet and asteroid contacts, and returns hit info (which asteroid, contact point, closing speed) for the upcoming collision-triggered damage in the fracture/mining systems.
Asteroids now hold real health instead of being binary objects: a hit that doesn't finish one off just wears it down (no debris); a lethal hit spawns 1-5 debris fragments, or 4-8 on a big-overkill hit, each with a deterministically hashed spawn direction/size (not Math.random()) so a given hit's outcome is reproducible. Debris never recursively fractures — a hard budget stop. Both collision (a fast graze now chips an asteroid's health) and the upcoming mining/weapon system route through this same applyAsteroidDamage pipeline. Debris is a pooled, quality-budgeted population (AsteroidDebris.tsx, same InstancedMesh swap-remove pattern as the voxel mining debris burst) that drifts freely in world space via the ship's existing integrate() helper, sticks-and-expires on any contact (planet, asteroid, other debris) rather than continuing to bounce, and is force-culled by lifetime and distance from the ship — all four budgets are new QUALITY tiers.
Raycasts a fixed screen-center beam (mirroring the voxel mining crosshair convention) against grid-shortlisted asteroid candidates only, feeding sustained fire into the same applyAsteroidDamage pipeline collision damage uses — holding the beam on target naturally produces low/medium/high fracture outcomes depending on how long it stays on target. Firing binds to left mouse (while pointer-locked), Space, or gamepad RB, none of which conflict with existing flight controls. A fraction of any fracture's debris (mining- or collision-triggered) comes back flagged as ore with a resource type from the existing inventory system; ore chunks within range home toward the ship (a one-way pull on the small pickup object, not a force on the ship, so it doesn't reintroduce a gravity well) and are collected into the same backpack voxel mining uses.
Adds a small, Act-1-scoped set of space points of interest — wreckage, a signal anomaly, a resource cluster, and a distant landmark — reusing the existing two-layer discovery architecture exactly as-is (recordDiscovery with planet: 'space', the existing 'signal' cross-body mystery) rather than a parallel narrative system. No new store field was needed: the existing `discovered` map already namespaces by "planet:id", so 'space' is just another valid planet string. Wreckage can anchor to a specific, stable belt asteroid (guaranteed to resolve to the same physical rock by the belt's determinism contract) and flags its host indestructible so it can't be blown apart by fracture testing. Discovery is proximity-triggered while piloting (fly close enough, no separate scan-button UI) — simpler than the voxel-surface manual-scan convention, appropriate since these are found while flying rather than on foot.
Adds Phase 11.8 covering ship collision, deterministic/structured asteroid placement, fracture/debris, space mining, and the space narrative/POI layer built on this branch, and notes the branch itself in the Branches & PRs section — unmerged, not yet manually playtested.
Fixes two real gaps: touch/mobile had no roll control at all (the on-screen joystick only ever set yaw/pitch, roll was hardcoded to 0), and touch had no way to fire the mining beam whatsoever (isFiring() only checked mouse/keyboard/gamepad) — independent of any range or quality-tier questions, touch players simply could not shoot or mine. - New ↺/↻ roll buttons and a FIRE button in TouchControls.tsx, grouped as a bottom-right thumb-reach cluster mirroring the game's own on-foot action-button convention (VoxelTouchControls.tsx), wired through new setTouchRoll/setTouchFiring exports. - Space mining now has visible feedback for every input method: a crosshair that highlights on-target (raycasts every frame instead of only while firing, published via a new spaceMiningTelemetry singleton), a visual beam line while firing, and an audible beam tone distinct from the engine hum. - Documented the fire control and touch roll buttons in ControlHints.tsx (English + Swedish), which previously never mentioned firing at all.
… knockback Firing is now a fixed-cadence stream of discrete hitscan shots while the trigger is held (6/sec, first shot immediate on trigger-pull) instead of a continuous damage-over-time beam — each shot flashes the tracer line briefly, plays a distinct laser blip (pitched differently for a hit vs. a miss), and, on a connecting hit, bursts a handful of short-lived impact-chip sparks at the point of impact regardless of whether the hit fractures the target. Sparks are a separate, cheap, quality-budgeted pool (miningSparkRuntime/MiningSparks.tsx) so they don't compete with the rarer, longer-lived fracture-debris budget. Asteroids also gain real impact physics: every hit (not just lethal ones) now applies a knockback impulse in the shot's direction of travel, giving asteroids a velocity they didn't have before. Tumbling- tier asteroids share their position Vector3 between the per-asteroid state and the render loop's rotation item, so integrating that velocity in AsteroidBelt's existing per-frame tumble pass is enough to visibly drift a hit rock — no separate update path needed. Dust-tier (non-rotating) asteroids don't redraw after their initial build, so knockback there is a no-op by design; they're too small to read knockback on anyway.
Replaces silent health-only sub-lethal hits with genuine local structural damage. On the first damaging hit, a tumbling-tier asteroid (budget/quality permitting) is "promoted" out of its shared InstancedMesh into a standalone Mesh with its own cloned, individually mutable geometry — dust-tier rocks are excluded (78% of the population, never redraw after initial build, too small to matter visually). Every subsequent non-lethal hit locally dents that geometry at the actual world-space impact point (reusing rockGeometry.ts's own per-vertex-mutation recipe, just scoped by distance falloff instead of applied uniformly), with a hard radial floor so repeated hits can't collapse a vertex through the core. Promoted asteroids also switch from a stateless clock-based tumble formula to a real integrated spin, needed so a later momentum-based fragment-velocity formula has an actual angular velocity to read. Falls back gracefully to today's health-only behavior when the promotion budget (new promotedAsteroidMax quality tier) is full or the asteroid is dust-tier — never blocks or crashes, just skips the visual upgrade for that hit. This is step 1 of a larger rework (see the approved plan) replacing "delete + spawn generic debris" with real composite-body physics; lethal fracture still uses the old jitter-based debris spawn for now — that becomes pattern-based chunk extraction with real momentum in the next step.
Lethal hits no longer spawn generic jitter-only debris — they extract real clusters of the asteroid's own geometry (precomputed once per base shape via nearest-seed-point clustering of face centroids, cached, then extracted fresh from the current, possibly-already-dented source buffer at fracture time) so fragments visually read as pieces of the specific rock that broke, not stock rubble. Fragment count is clamped to the chosen pattern's chunk count so no two fragments share a shape. Fragment velocity is now real rigid-body momentum instead of jitter alone: parent linear velocity + parent angular velocity at the fragment's offset-from-center, plus the existing impact-direction/ force bias. Fragments also carry their own spin (quaternion + angular velocity, deterministically hashed, faster for smaller/faster ejecta) and debris rendering now composes that rotation instead of ignoring it entirely, which it did before this change. Falls back to the previous jitter-only spawn when no promotion API is registered (defensive; in practice always available once a belt is mounted) — keeps existing debris-count/ore-fraction test coverage valid unchanged.
…chips Debris now reflects off planets/asteroids/other debris (reflectSphereContact, a real bounce with restitution + tangential energy bleed) instead of disappearing on first contact. A hard-enough hit chips 1-2 small secondary fragments off a cascadeDepth:0 fragment, capped to exactly one extra level so population growth stays bounded.
Pattern-based fragments now carry a shapeKey (tier/variant/pattern/cluster) threaded through from asteroidFracture.ts; AsteroidDebris.tsx renders each distinct shape in its own lazily-built InstancedMesh bucket (extracted from the belt's shared base geometry) so a fragment visually reads as the actual chunk it broke off, falling back to a generic rock bucket for jitter-only fragments and cascade chips.
const over let for a never-reassigned array, and copy ref.current into a local before using it inside an effect's cleanup closure.
✅ Deploy Preview for lundstedt-solar-system ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
… it entirely QUALITY.low had asteroids/debrisMax/miningVfxBudget all at 0, so on any device that auto-detects 'low' (mobile, <=4 cores/4GB, or no WebGPU) the entire mining/asteroid system was silently absent — no asteroids to shoot, no sparks, no debris, matching the reported "no visible shots and no debris." Gave low a modest nonzero budget for all three so mining stays a functional gameplay loop on every tier, and bumped the shot-flash duration slightly (0.06s -> 0.1s) for perceptibility. Also wired up cascadeFractureEnabled, which was defined and documented in quality.ts but never actually read anywhere — a dead config field from the earlier cascade-bounce work. debrisRuntime now carries a cascadeEnabled flag that gates secondary-fracture chip spawning (debris still always bounces; this only caps the extra population growth on low-end tiers).
…ting Verified via live-instrumented testing against the running app (not just code reading) that the damage -> fracture -> debris -> render pipeline itself works correctly end-to-end: a lethal hit properly kills the asteroid and spawns real, well-formed fragments that get mounted for rendering with no errors. The reported "asteroid changes shape but no debris ever breaks off" is explained by health tuning, not a rendering bug: the old curve (8 + r*18) made bigger, more visibly-denting rocks take up to ~5.6s of perfectly concentrated fire to kill. In a dense belt it's easy for aim to drift onto a neighboring rock between shots, spreading damage thin across many asteroids (visible dents) without ever finishing one off (no debris). Retuned to 5 + r*10 so a realistic couple-second burst reliably kills even the largest tier-2 rocks.
Verified live (headless-browser instrumentation against the running dev build) that the beam's fire/flash logic was already 100% correct - visible ~60% of frames at the intended duty cycle, always attached to the scene - but a three.js Line is a 1px hairline regardless of the `linewidth` material property in WebGL/WebGPU, which reads as imperceptible at a 0.1s flash against a starfield, especially on a phone. Swapped it for a thin CylinderGeometry mesh (oriented via quaternion, scaled to the shot's length) so the tracer has real, guaranteed on-screen width.
Shots now physically originate from a weapon hardpoint on the ship (not the camera), travel through space at a finite speed (220 u/s + the ship's own velocity — real momentum transfer), and are resolved by a per-frame swept-segment collision test against asteroids/planets, so a fast bolt can't tunnel through a small target between frames. Damage, knockback, and debris still route through the exact same applyAsteroidDamage pipeline as before, now triggered at the projectile's true point of contact instead of an instant camera raycast. New: src/scene/projectileRuntime.ts (pooled runtime, same convention as debrisRuntime.ts), src/systems/projectilePhysics.ts (integrate + swept collision, unit tested including a tunneling-prevention case), src/scene/Projectiles.tsx (InstancedMesh render, budget-capped by the new QUALITY[...].projectileMax tier). Added raySphereHit/raycastPlanets as pure (non-mutating) primitives in shipCollision.ts for the projectile's planet-collision check, factored out of the existing sphere-contact math. The crosshair's continuous "is something targetable" aim-assist raycast is unchanged — only the weapon's actual hit resolution moved from instant camera-based to projectile-travel-based. SpaceMiningController.tsx no longer renders anything itself (the flash-beam mesh is gone, superseded by the traveling bolt); it now only owns fire cadence, hardpoint placement, launch audio, aim telemetry, and ore magnetism/collection.
Two bugs found by instrumenting the live app, both confirmed fixed by re-running the same end-to-end scenario (hold fire aimed at a real belt asteroid: 16 auto-shots over a 2.5s hold, health 30 -> killed, knockback velocity imparted, debris fragments present in the world afterward): 1. Converged aim. Projectiles spawned at the ship's hardpoint but flew parallel to the CAMERA ray. The chase camera sits a couple of units behind/above the ship, so the bolt's flight line was laterally offset from the crosshair line by more than a small asteroid's radius - shots consistently missed exactly what the reticle said was targetable, which is why hits (and therefore knockback and debris) never happened. Shots now fly from the hardpoint toward the crosshair's actual world target point. 2. Belt-frame/world-frame mismatch in fracture spawns. state.pos/vel are belt-LOCAL (the whole belt group rotates by groupYaw), but debris simulates in WORLD space. Fragments spawned at the local position - measured live at 3452 units away from the same asteroid's world position (groupYaw was 8.7 rad) - and were instantly removed by the 400-unit distance cull, so even a landed kill produced zero visible debris. The knockback impulse had the mirror bug (world-space direction added to local-frame velocity, pushing rocks ~140 degrees off the shot line). Fragment kinematics are now computed in the belt frame and rotated to world for the spawn; knockback direction is rotated into the belt frame. Two yaw regression tests added.
Two compounding causes of "still no debris from the shots," found by sampling debris state live across a burst-fire sequence against a real belt asteroid: 1. Non-lethal hits (the vast majority of shots — an asteroid takes several hits to kill) produced zero debris at all, only a health decrement + dent. Every connecting hit now also chips off one small, short-lived fragment at the exact impact point, giving instant visual feedback on every shot, not just the eventual kill. Chips are pre-aged (spawned with `life` already advanced) so sustained fire can't fill the shared debris pool and starve real fracture fragments of budget. 2. Fragment ejection speed was derived directly from the projectile's own flight speed (~220 u/s, after the earlier projectile-weapon rework), uncapped — fracture fragments launched at up to ~90-150 u/s, fast enough to streak out of the debris-cull radius within a fraction of a second. Technically "spawned" (confirmed in the prior round's live check) but functionally invisible in normal play. Ejection energy is now capped at a physically-motivated fraction of the impact speed (MAX_EJECTION_SPEED = 30), verified live to keep fragment speeds in a watchable ~6-7 u/s range against a real hit sequence. Existing "low-impact -> no debris" tests updated to the new "low tier -> one chip" contract; two new assertions cover the chip's short lifetime and cascade-depth-1 (never re-fractures further).
Hold-to-open radial tool selector (Q on desktop, a new touch button) — drag to a wedge, release to select. Deliberately not modal like the existing Backpack/Craft/Silo sheets: pointer-lock stays engaged and the game keeps simulating while it's open, since opening it just redirects raw look-deltas into the wheel's own drag vector instead of the camera. Pickaxe: unchanged mining behavior, now gated to only work when it's the equipped tool (previously mining was unconditional). Gun: a second, longer-range mining tool (10 vs the pickaxe's 6), automatic fire at 4 shots/sec, reusing the same aim-raycast/crack-progress/break pipeline as the pickaxe. Reduced resource yield and can't chop trees, so the pickaxe stays meaningful rather than being strictly superseded. Flashlight: a real SpotLight, toggled via the existing primary-click input (repurposed only while equipped, since mining is a no-op for this tool). Requires a genuine darkness system, added alongside it: ambient light now dims with cave depth (an existing depth signal that previously only fed audio) and, independently, on bodies whose biome archetype is already naturally dark (airless/moon-type biomes already have low authored ambient values — no new per-body data needed). Both factors compound, so a dark body's caves are the darkest of all. Found and fixed a real bug along the way: the WebGPU renderer only registers AmbientLight/PointLight/DirectionalLight with its node library (the same class of bug already fixed for DirectionalLight earlier in this project) — a SpotLight would have silently never lit anything without also registering SpotLightNode. New pure/testable modules: voxelControls.ts's resolveWheelSlice (wheel drag-vector -> selected tool) and darkness.ts's ambientDarknessFactor (cave + dark-body dimming), both unit tested. Verified live against the running app (headless browser): tool equip via the wheel gesture, the darkness formula's actual output, and that toggling the flashlight produces no WebGPU light-node warnings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds the space-flight layer on top of
storyline: ship-vs-planet/asteroid collision, a deterministic structured asteroid belt with per-instance state and a spatial grid, an automatic-fire mining/weapon system, and a space narrative/POI layer — capped off by a full rework of asteroid destruction into a genuine composite-physical-body system per explicit user direction (not "delete + spawn generic replacement").Asteroid destruction rework (the bulk of recent work)
InstancedMeshpool into a standalone, individually-mutable mesh, so sub-lethal hits leave a real, persistent, deepening crater at the impact point instead of silently decrementing health.InstancedMeshbuckets (keyed by tier/variant/pattern/cluster), extracted from the belt's shared base geometry, so a fragment visually reads as the actual chunk it broke off instead of a generic rock.Also in this branch
Test plan
npx tsc --noEmit— cleannpm run lint— cleannpm test(vitest) — 208 tests passingnpm run build(tsc + vite production build) — succeedsGenerated by Claude Code